原文地址:https://jhalon.github.io/chrome-browser-exploitation-3/
欢迎阅读“Chrome浏览器漏洞利用”系列的第三部分,也是最后一部分。本系列文章的主要目的是介绍浏览器的内部结构,并更深入地探讨Windows上Chrome浏览器的漏洞利用。
在本系列的第1部分中,我们研究了JavaScript和V8的内部工作原理。这包括对对象、映射和形状的探索,以及对内存优化技术(如指针标记和指针压缩)的概述。
在本系列的第2部分 中,我们更深入地研究了V8编译器工作流程。我们研究了Ignition、Sparkplug和TurboFan在工作流程中的作用,并讨论了V8的字节码、代码编译和代码优化等主题。
在今天的博客文章中,我们将重点介绍CVE-2018-17463的分析和利用,这是一个在TurboFan中的JIT编译器漏洞。此漏洞是由于在 lowering optimization阶段对JSCreateObject操作进行了不恰当的副作用建模而产生的。在深入研究利用这个错误之前,我们将首先了解基本的浏览器利用原语,例如addrOf和fakeObj,以及如何使用我们的错误来利用类型混淆。
本文将讨论以下主题:
- 理解补丁间隙
- CVE-2018-17463漏洞根本原因分析
- 设置环境
- 生成poc
- 利用JSCreateObject的类型混淆
- 理解浏览器利用原语
- addrOf读原语
- fakeObj写原语
- 获取内存任意地址读写能力
- 获取代码执行能力
- WebAssembl基础解析
- 滥用WebAssembly内存
- 总结
- 参考链接
好了,说了这么多,让我们开始!
理解补丁间隙
2018年9月,Beyond Security的SecuriTeam将Issue 888923报告给谷歌的安全团队。该漏洞是Samuel Gross通过源代码审查发现的,并被用作Hack2Win竞赛的一部分。bug修复一个月后,SSD发布了一份名为“Chrome Type Confusion in JSCreateObject Operation to RCE”的公告,其中提供了一些关于bug的细节,并发布了关于其利用的POC。
同月,Samuel在BlackHat 2018上做了一个名为“Attacking Client-Side JIT Compilers”的演讲,他在演讲中讨论了JIT编译器中的漏洞,特别是与冗余消除和IR中的副作用建模相关的漏洞。直到2021年,Samuel又发布了一篇题为“Exploiting Logic Bugs in JavaScript JIT Engines”的Phrack文章,该文章更深入地解释了CVE-2018-17463是如何被发现和利用的。
值得注意的是,关于这个漏洞的大量信息在发现后的几周内就被公开了。这意味着攻击者可以使用这些信息来逆向工程并利用该漏洞。然而,问题是,大多数Chrome浏览器在最初提交修复后的几天甚至几周内就已经自动打好了补丁,使该漏洞变得毫无用处。
许多攻击者和利用漏洞的工程师不是依赖于潜在漏洞的公开信息,而是跟踪提交,寻找特定的关键字。当他们发现一个看起来有类似的提交时,他们会试图找出潜在的错误,这种做法被称为“补丁间隙”。
正如 Exodus的文章“Patch Gapping Google Chrome”所解释的那样,他们将补丁漏洞详细描述为“在实际的补丁交付给 用户之前,开发人员已经修复(或正在修复)的开源软件漏洞的尝试”。
为什么这与我们关于Chrome浏览器开发的讨论有关?好吧,通过理解补丁间隙的概念,它允许我们采取更多的“对手心态”。在学习了这么多关于V8内部的知识之后,我们现在应该有了足够好的领悟,能够从最初的提交中发现Chrome代码中的潜在错误。
通过采用这种方法,我们可以扩大利用漏洞的机会窗口,也可以扩大我们对Chrome代码库的了解。此外,通过观察代码中经常被修补的位置,我们可以了解我们应该在哪里寻找Chrome中潜在的零日漏洞。
考虑到这一点,让我们通过查看为修复此错误而推送的初始commit来开始根因分析。我们将尝试对修复程序进行逆向,并使用我们获得的知识找出如何触发错误。如果我们陷入困境,我们会利用现有的公共资源来帮助我们。毕竟,这是一个通过浏览器利用的旅程,有时这个旅程从来都不容易!
CVE-2018-17463漏洞根本原因分析
查看 Issue 888923,我们可以看到这个错误的初始补丁是通过commit 52a9e67a477bdb67ca893c25c145ef5191976220推送的,并带有“[turbofan] Fix ObjectCreate ‘s side effect annotation”的信息。了解了这些,让我们使用V8目录下的git show命令来查看提交修复了什么。
1 | C:\dev\v8\v8>git show 52a9e67a477bdb67ca893c25c145ef5191976220 |
在检查这个提交之后,我们可以看到它只修复了src/compiler/js-operator.cc
文件中的一行代码。修复只是将Operator::kNoWrite
标记替换为Operator::kNoProperties
标记用于CreateObject
JavaScript操作。
如果您还记得在本系列的第2部分中,我们简要地讨论了这些标志,并解释了中间表示(IR)操作使用它们。在这种情况下,kNoWrite
标志表示CreateObject
操作不会产生可观察到的副作用,换句话说,不会对上下文的执行产生可观察到的变化。
这给编译器带来了一个问题。正如我们所知,某些操作可能会产生副作用,导致上下文发生可观察到的变化。例如,如果传入的对象的对象Map发生了变化或修改——这是一个可观察的副作用,需要将其写入操作链。否则,某些优化通过,比如冗余消除,可能会删除编译器认为是“冗余”的CheckMap
操作,而实际上它是必需的检查。从本质上讲,这可能导致类型混淆漏洞。
因此,让我们验证CreateObject
函数是否确实具有可观察的副作用。
要确定IR操作是否有副作用,我们需要查看优化编译器的lowering 阶段。这个阶段将高级IR操作转换为用于JIT编译的低级指令,同时也是消除冗余的地方。
对于CreateObject
JavaScript操作,lowering发生在v8/src/compiler/js-generic- reducing.cc
源文件中。特别是在LowerJSCreateObject
函数中。
1 | void JSGenericLowering::LowerJSCreateObject(Node* node) { |
看一下lowering函数,我们可以看到JSCreateObject
IR操作将降低为对内置函数CreateObjectWithoutProperties
的调用,该函数位于v8/src/builtins/object.Tq源文件
中。
1 | transitioning builtin CreateObjectWithoutProperties(implicit context: Context)( |
这个函数中有很多代码。我们不需要完全理解它,但简单地说,这个函数开始创建一个没有属性的新对象。这个函数的一个有趣的方面是对象原型prototype的typeswitch。
这对我们来说很有趣的原因是因为V8中的一个优化技巧。在JavaScript中,每个对象都有一个私有属性,该属性包含到另一个对象(称为原型)的链。简单来说,原型类似于c++中的类,其中对象可以继承某些类的特性。该原型对象有自己的原型,原型的原型也有自己的原型,形成了一个“原型链”,一直到到达一个值为null
的对象。
在这篇文章中,我不会过多地讨论原型的细节,但是你可以阅读“Object Prototypes”和“Inheritance and the Prototype Chain”来更好地理解这个概念。现在,让我们关注V8中原型的有趣优化。
在V8中,每个原型都有一个独特的Shape,不与任何其他对象共享,特别是不与其他原型共享。每当一个对象的原型被改变时,就会为该原型分配一个新的Shape。我建议阅读“JavaScript Engine Fundamentals: Optimizing Prototypes”以获得关于这种优化的更多信息。
正因为如此,我们希望仔细查看这里的代码,因为原型的优化是一个副作用,如果没有正确建模,可能会产生副作用。
最后,CreateObjectWithoutProperties
函数调用了ObjectCreate
函数,它是c++运行时内置的,位于v8/src/objects/js-objects.cc
中。在2018年的代码库中,这个函数位于v8/src/objects.cc
文件中。
1 | // 9.1.12 ObjectCreate ( proto [ , internalSlotsList ] ) |
窥探一下ObjectCreate
函数,我们可以看到这个函数基于之前对象的原型,使用GetObjectCreateMap
函数为对象生成了一个新的Map,该函数位于v8/src/objects/map.cc
中。
此时,我们应该已经开始看到这个JavaScript操作符中潜在的副作用在哪里。
1 | // static |
在GetObjectCreateMap
函数中,我们可以看到两个对JSObject::OptimizeAsPrototype
和Map::TransitionToPrototype
的有趣调用。这对我们来说很有趣,因为这段代码暗示并进一步确认了新创建的对象被转换为原型对象,这也改变了对象的关联Map。
了解了这一点,让我们跳转到d8
并验证Object.Create
函数确实以某种方式修改对象和Map,这对我们来说是可利用的。首先,让我们使用--allow-native -syntax
选项启动d8
并创建一个新对象,如下所示。
1 | let obj = {x:13}; |
从这里开始,让我们对对象执行%DebugPrint
函数,以查看它的Map和相关属性。
1 | d8> %DebugPrint(obj) |
从最初的输出回顾中,我们可以看到对象的Map是FastProperties的Map,它对应于具有 in-object属性的对象。现在,让我们对obj对象执行Object.create
函数,并打印其调试信息。
1 | d8> Object.create(obj) |
如你所见,当调用Object.create
,对象的Map将从具有 in-object 属性的FastProperties
映射更改为DictionaryProperties
Map,其中这些属性现在存储在字典中。这个副作用使ObjectCreate
中间表示(IR)操作的kNoWrite
标志失效,证明这个假设是有缺陷的。
在这种情况下,如果我们可以在调用Object.create
之前通过冗余消除来消除CheckMap
操作。那么我们就可以触发类型混淆漏洞。当引擎试图访问属性backing store中的out-of-line 属性时,就会发生类型混淆。引擎期望属性备份存储是一个FixedArray
,其中每个属性一个接一个地存储,但现在它将指向一个更复杂的NameDictionary
。
设置环境
在我们继续分析和利用这个错误之前,我们首先需要设置我们的开发环境。如果您从第1部分开始就一直在关注本博客系列文章,那么在遵循我的“Building Chrome V8 on Windows”指南中的说明之后,您可能已经拥有了d8的工作版本。
由于这个bug来自2018年,所以Chromium代码库已经发生了很多变化,构建新版本所需的依赖关系也发生了变化。为了重现这个错误,你可以简单地将下面的diff补丁应用到src/compiler/js-operator.cc
文件:
1 | diff --git a/src/compiler/js-operator.cc b/src/compiler/js-operator.cc |
然而,在我的测试中,虽然我能够触发错误,但我不能真正得到一个工作类型混淆和滥用addrOf和fakeObj原语(我们将在后面讨论)。我不确定为什么会出现这种情况,但可能是2018年至2022年之间的代码更改修补了这些原语所需的部分代码库。
更新:在diff补丁之后,这种类型混淆在V8的新版本上不起作用的原因是V8 Heap Sandbox 被启用了。这个沙盒本质上防止了攻击者破坏V8对象,比如
ArrayBuffer
。在应用补丁后,可以通过将
V8\_VIRTUAL\_MEMORY_CAGE
标志设置为False
来禁用V8堆沙盒,这是在Change 3010195中引入的。我还没有亲自测试过这个功能,所以我不能保证它能正常工作。
相反,我选择做的是检查bug修复前的最后一个“漏洞”提交,并再次构建v8和d8。这本身就带来了一些问题,因为在2018年Chrome需要Visual Studio 2017,但在我们目前的环境中,我们有Visual Studio 2019。虽然仍然可以使用Visual Studio 2019构建Chrome,但我们需要先安装一些先决条件。
要开始,请打开Visual Studio 2019安装程序,并安装以下其他组件:
- MSVC v140 - VS 2015 c++构建工具(v14.00)
- MSVC v141 - VS 2017 c++ x64/x86构建工具(v14.16)
- Windows 10 SDK (10.0.17134.0)
一旦安装了这些组件,我们需要添加以下环境变量:
- 添加vs2017_install用户变量并将其设置为
C:\Program Files (x86)\Microsoft Visual Studio 14.0
- 添加
C:\Program Files (x86)\Windows Kits\10\bin\10.0.17134.0\x64
到用户Path变量。
配置好之后,我们现在需要修改V8代码库。如果我们查看提交52a9e67a477bdb67ca893c25c145ef5191976220的git
日志,我们会看到在错误修复之前的最后一次脆弱提交是568979f4d891bafec875fab20f608ff9392f4f29
。
有了这个提交,我们可以运行git checkout
命令来更新V8目录中的文件,并匹配上一次易受攻击的提交的版本。
1 | C:\dev\v8\v8>git checkout 568979f4d891bafec875fab20f608ff9392f4f29 |
设置好之后,删除v8\v8\out\
文件夹中的x64.debug
目录以避免错误。接下来,修改构建/toolchain/win/tool_wrapper.py
构建脚本,以匹配tool\_wrapper.py
文件的内容,在应用修复程序以删除由于Issue 1033106中报告的构建错误而导致的the superflush hack。
一旦修改了tool_wrapper.py
文件,就可以使用以下命令 build d8的调试版本:
1 | C:\dev\v8\v8>gn gen --ide=vs out\x64.debug |
这个构建可能需要一段时间才能完成,所以在等待的时候去喝杯咖啡。☕
构建完成后,您应该能够启动d8,并成功运行 SSD Advisory的poc.js脚本,以确认您可以创建可工作的读/写原语。
生成poc
现在我们有了一个存在漏洞的V8版本,并且理解了潜在的bug,我们可以开始编写POC了。让我们先来回顾一下我们需要这个POC来做什么:
- 创建一个带有内联属性的新对象,该对象将用作
Object.create
的原型。 - 向对象的属性backing store添加一个新的out-of-line属性,我们将在Map转换后尝试访问该属性。
- 强制在对象上执行
CheckMap
操作以触发冗余消除,这将删除后续的CheckMap
操作。 - 之前创建的对象调用
Object.create
,以强制Map转换。 - 访问对象的out- line属性。
- 由于
CheckMap
冗余消除,引擎将解引用属性指针,认为它是一个数组。但是,它现在指向一个NamedDictionary
,允许我们访问不同的数据。
- 由于
从表面上看,这似乎很简单。然而,重要的是要注意,bug在实践中往往比理论上更复杂,特别是在触发或利用它们时。因此,最困难的部分通常是触发错误并使类型混淆。一旦实现了这一点,开发的过程往往会更加顺利。
那么,我们该如何开始呢?
幸运的是,如果我们检查52a9e67a477bdb67ca893c25c145ef5191976220
的diff,我们会注意到Chrome团队在提交中添加了一个回归测试用例。回归测试用于验证应用程序的任何更新或修改都不会影响其整体功能。在这种情况下,回归文件似乎是在测试我们的错误!
让我们看一下这个测试用例,看看我们可以使用什么。
1 | // Flags: --allow-natives-syntax |
从代码的一开始,我们可以看到创建了一个接受对象o的新函数f。当这个函数被调用时,它对传入的对象执行以下操作:
- 它访问对象o的属性x,这将强制执行CheckMap操作。
- 在对象o上调用
Object.create
,这将强制Map转换。 - 访问传入对象 y 中 a 的越界属性,这应该会触发类型混淆。
我们可以看到这个函数用简单的对象和属性调用了两次,然后调用了%OptimizeFunctionOnNextCall
,这迫使V8将该函数传递给TurboFan进行优化。这可以防止我们需要运行一个循环来使函数“hot”。然后第三次调用该函数,这应该会触发我们的错误。
如您所见,调用assert方法是为了检查返回的值是否为3。如果不是,则可能bug仍然存在。
这对我们很有帮助,因为我们现在有了一个可用的POC。虽然,我不确定为什么他们在属性后台存储中使用对象而不是值。我想我们以后会明白的。
有了这些信息,让我们使用收集到的信息构建自己的POC脚本。之后,我们将执行一些检查,以确保我们确实存在工作类型混淆,并且我们还将使用Turbolizer
来验证CheckMap
操作确实通过消除冗余删除了。
我们的概POC应该是这样的:
1 | function vuln(obj) { |
现在我们已经创建了概念证明,让我们用--allow-naitives-syntax
标志启动d8
,并添加我们的vuln
函数。函数创建之后,让我们执行概念验证中的最后4行代码。您应该看到以下输出:
1 | d8> vuln({a:42, b:43}) |
这样,我们就有了可用的POC!可以看到,优化后的函数不再返回43,而是返回0。
在我们进一步研究这个错误并尝试实现工作类型混淆之前,让我们使用--trace-turbo
标志运行这个脚本,并在每个优化阶段检查IR,以确认CheckMap
节点确实已被删除,并且这不是侥幸。
1 | C:\dev\v8\v8\out\x64.debug>d8 --allow-natives-syntax --trace-turbo poc.js |
创建了turbo文件之后,让我们检查typer优化阶段以查看初始IR图。
对IR的初步审查显示了我们的预期。如您所见,Parameter[1]
节点传递了函数的对象。该对象通过CheckMaps
操作验证映射,然后调用LoadField
操作返回属性a。
接下来,调用JSCreateObject
将对象修改为原型。之后,IR执行CheckMaps
操作来验证对象的Map,然后调用LoadField
操作来返回属性b。这是应该保留的预期副作用流。
现在,让我们看看lowering 阶段之后的IR。由于CreateObject
不写入副作用链,CheckMaps
节点应该由于冗余消除而不再存在。
正如您在simplified lowering 阶段所看到的,在JSCreateObject
调用之后,我们之前的CheckMaps
节点现在已经被删除,它直接调用LoadField
节点。
现在我们已经确认JIT代码确实删除了CheckMaps
节点,让我们修改我们的POC,不使用%OptimizeFunctionOnNextCall
,而是将代码放入循环中,以便JIT在执行时接管。
此外,这次让我们向对象添加一个out- line属性,这样我们就可以强制JIT以数组的形式访问后备存储区,这将引发类型混淆。
我们更新后的POC如下所示:
1 | function vuln(obj) { |
在更新这段代码并使用--trace-turbo
标志运行它之后,我们可以再次确认我们有一个可用的类型混淆。正如我们在IR中看到的,编译器访问对象的backing store指针的偏移量为8,然后在数组中加载属性b,它认为属性b的偏移量为16。然而,它将访问数据的另一个区域,因为它不再是一个数组,而是一个字典。
利用JSCreateObject的类型混淆
现在我们有了一个可用的类型混淆,V8将NamedDictionary
作为数组访问,我们必须弄清楚如何滥用这个漏洞来获得对V8堆的读写访问权。
与许多漏洞不同,此漏洞不涉及内存破坏缺陷,因此不可能溢出缓冲区并控制指令指针(RIP)。但是,类型混淆漏洞允许我们在对象的内存布局中操作函数指针和数据。例如,如果我们可以覆盖指向一个对象的指针,并且V8解引用或跳转到该指针,我们就可以实现代码执行。
不幸的是,我们不能盲目地在没有一定精度的情况下开始向对象读写数据。正如在上面的IR中所看到的,我们通过在数组中指定一个属性来控制V8在哪里读写数据。但是,由于类型混淆,该数组被转换为NameDictionary
,这意味着布局发生了变化。
要利用这个漏洞,我们需要了解这两种对象结构的不同之处,以及我们如何操纵它们来实现我们的目标。
从第1部分中我们知道,数组只是一个FixedArray
结构,它一个接一个地存储属性值,并通过索引访问。正如您在上面的IR中所看到的,第一个LoadField
调用位于偏移量8,这将是JSObject
中的属性备份存储指针。由于在backing store中只有一个out-of-line属性,我们看到第二个LoadField
在偏移量16处访问第一个属性,最初跳过Map和Length。
在从数组到字典的转换过程中,我们还知道所有属性元数据信息不再存储在Map中的描述符数组中,而是直接存储在属性backing store中。在这种情况下,字典将属性值存储在名称、值和细节三组动态大小的缓冲区中。
从本质上讲,NameDictionary
结构比我们在第1部分中详细介绍的要复杂得多。为了更好地理解NameDictionary
的内存布局,我在下面提供了一个可视化示例。
如您所见,NameDictionary
确实存储了属性三元组以及与字典中元素数量相关的额外元数据。在本例中,如果我们的类型混淆像上面的IR一样读取偏移量16处的数据,那么它将读取存储在字典中的NumberOfElements。
为了验证这些信息,我们可以重用POC脚本,并在WinDbg中设置断点来检查对象的内存布局。调试这些概念验证脚本的一个简单方法是在/src/runtime/runtime-test.cc
源文件中的RUNTIME\_FUNCTION(Runtime\_DebugPrint)
函数上设置一个断点。这将在%DebugPrint
被调用时触发,允许我们从d8获得调试输出,并进一步分析WinDbg中的漏洞。
让我们从修改概念证明开始,在对象更改前后添加DebugPrint
命令。脚本应该是这样的:
1 | function vuln(obj) { |
为了帮助分析对象的内存布局,我们修改概念验证脚本以在两个点打印对象信息:一次是在迭代1中设置其属性之后,一次是在迭代9999中JIT启动并修改对象之后。
要调试这个脚本,我们可以在WinDbg中使用--allow-native -syntax
标志启动d8,后面跟着概念验证脚本的位置。例如:
完成后,按Debug。这将启动d8并到达WinDbg设置的第一个调试断点。
1 | (17f0.155c): Break instruction exception - 80000003 (first chance) |
现在我们可以在WinDbg中使用x V8 !*DebugPrint*
命令搜索V8的源代码。您应该得到如下所示的类似输出。
1 | 0:000> x v8!*DebugPrint* |
我们将在v8!v8::internal::Runtime_DebugPrint
函数上设置断点。可以通过在WinDbg中运行以下命令来做到这一点。
1 | bp v8!v8::internal::Runtime_DebugPrint |
一旦配置了断点,按Go或在命令窗口中键入g,我们应该会碰到DebugPrint断点。
您可能注意到,即使命中了断点,d8中也没有输出。为了解决这个问题,我们可以通过单击第542行并按F9来设置断点。然后,我们可以按Shift + F11或“Step Out”继续执行,并在d8中看到调试输出。
1 | DebugPrint: 000000C44E40DAD9: [JS_OBJECT_TYPE] |
在检查输出时,我们可以看到对象有一个内联属性和一个 out-of-line属性,该属性应该在属性备份存储中,地址为0x00c44e40db81。让我们用WinDbg快速查看对象以验证该地址。
1 | 0:000> dq 000000C44E40DAD9-1 L6 |
我们注意到一些不同的东西。虽然对象结构与调试输出中的地址匹配,但我们注意到这些是完整的32位地址。这是因为在这个版本的V8中,指针压缩还没有实现,所以V8仍然使用完整的32位地址。因此,存储在对象结构中的值不再加倍。这可以通过验证0x2a的十六进制值实际上是42来确认,这是第一个内联属性的值。
了解了这一点,让我们通过检查WinDbg中的内存内容来验证我们的属性数组backing store结构。
1 | 0:000> dq 0x00c44e40db81-1 L6 |
这样做后,我们看到b属性(值为43或十六进制中的0x2b)位于属性后台存储区中数组的偏移量16处。
现在我们已经验证了我们的对象结构,让我们按Go,然后按Shift + F12,在触发bug后获得修改后的对象的输出。
1 | DebugPrint: 000000C44E40DAD9: [JS_OBJECT_TYPE] |
触发bug后,我们可以看到对象的Map已经改变,属性存储已经转换为大小为29的NamedDictionary
。通过检查WinDbg中的对象结构,我们可以确认属性backing store 地址现在是0x00c44e40dba9。
1 | 0:000> dq 000000C44E40DAD9-1 L6 |
的确如此!接下来,让我们查看地址为0x00c44e40dba9的字典结构。
1 | 0:000> dq 0x00c44e40dba9-1 L12 |
在检查这个地址的字典结构后,我们可以看到它与FixedArray对象结构有很大不同。此外,我们看到第二个属性a(42或0x2a)的值在该结构中的偏移量为88,而第二个属性b(43或0x2b)的值不在预期位置。这个值很可能位于字典内存布局的更深处。
现在您可能会问自己,在字典结构中,000002c9f87825a1这样的奇怪数值是什么?字典实际上是一个HashMap
,它使用哈希表将属性的键映射到哈希表中的位置。您看到的奇数值是哈希码,这是对给定键应用哈希函数的结果。
在字典的顶部,我们可以看到对象的Map位于偏移量0,字典的长度(29或十六进制中的0x1d)位于偏移量8,字典(2)中的元素数量位于偏移量16。
在我们的例子中,当我们访问b属性时,V8将访问字典中的元素数(由IR确认,应该是2)。触发错误后在d8中运行这段代码,它确实返回2。
1 | d8> %OptimizeFunctionOnNextCall(vuln) |
完美!我们刚刚确认了我们的类型混淆是有效的,并且我们可以通过指定属性来控制可以在字典中访问的数据类型。这将允许我们为每个属性遍历字典8个字节。
现在,让我们回到关于在尝试向对象读写数据时具有精度的讨论。如您所见,使用两个属性,我们只能读取字典中的NumberOfElements。这并没有给我们带来多少好处,因为通常我们对结构的这一部分没有太多的控制,因为它是动态分配的。
我们要做的是在字典中获得对属性值的读写访问权,因为只需指定属性索引,就可以轻松地对属性值进行数据读写。
正如我们已经看到的,我们的第一个属性值在数组中的偏移量为16时,在字典中的偏移量为88。因此,如果我们要添加88/8=11个不同的属性,我们应该能够通过从backing store(数组中应该是88字节或10x8+8)访问属性10来读写字典中的第一个属性。
这意味着对于FixedArray
中的每N个属性,我们将在字典中有少量重叠的属性,它们位于相同的偏移量。
为了帮助您可视化这一点,下面是一个FixrdArray的内存转储示例,其中包含11个属性和一个具有重叠属性的NameDictionary。
1 | FixedArray NameDictionary |
正如内存转储中所示,通过从FixedArray访问属性10,我们可以在触发错误并将FixedArray转换为NameDictionary之后访问属性1的值。这本质上允许我们读取和写入字典中属性1的值。
然而,这种方法存在一个问题:NameDictionary
的布局在引擎的每次执行中都是不同的,这是由于哈希映射表的哈希机制中使用的进程范围内的随机性。这可以通过重新运行概念证明并在触发错误后检查字典结构来验证。你的结果会有所不同,但在我的例子中,我有以下输出:
1 | 0:000> dq 0x025e3e88dba9-1 L12 |
正如我们所看到的,属性b(值为43或0x2b)现在位于字典中的偏移64,属性a不在预期的位置。在本例中,属性a的偏移量为184。这意味着我们前面使用11个属性的示例将无法工作。
尽管这些属性的顺序不是已知的,甚至是无法猜测的,但我们仍然知道可能存在一对属性P1和P2,它们最终会在相同的偏移处重叠。如果我们能够编写一个JavaScript函数来查找这些重叠的属性,那么我们至少能够在向属性读取和写入新值时获得一定的精度。
在写这个函数之前,我们需要考虑要生成多少属性才能找到这个重叠。好吧,由于[in-object slack tracking](https://jayconrod.com/posts/52/a-tour-of-v8--object-representation#:~:text=V8 uses a process called,properties stored within the object.)快速属性的最佳数量是32,所以我们将使用它作为我们的最大值。
让我们通过创建一个新函数来重新定义POC,该函数创建一个具有一个内联属性和32个out-of-line属性的对象。该函数的代码如下:
1 | function makeObj() { |
在函数中需要注意的一件事是,我们对i使用了负值。这样做的原因是字典中有一些不相关的小正值,比如元素的长度和数量。如果我们为属性值使用正值,那么在搜索重叠属性时就有得到假阳性的风险。因此,我们使用负数来区分我们的属性与这些不相关的值。
从这里开始,我们可以开始编写搜索重叠属性的函数。我们要做的一个修改是对我们的vuln函数,它之前触发了bug并返回了对象的属性b。在本例中,我们希望返回所有属性的值,以便在数组和字典之间进行比较。
为此,我们可以使用带template literals的eval函数在运行时生成所有返回语句,只需几行代码。下面的代码允许我们这样做:
1 | function findOverlappingProperties() { |
如果您对eval函数的最后两行感到困惑,这里有一个简短的解释。我们在模板中使用模板字面量(反引号)和占位符,它们是由$符号和花括号分隔的嵌入表达式:${expression}。当我们在运行时调用vuln函数时,这些表达式将进行字符串插值,表达式将被生成的字符串替换。
在我们的例子中,我们在pNames数组上使用map函数来创建一个新的字符串数组,它将等价于let p1 = obj.p1。这允许我们在运行时生成这些代码行来设置和返回所有属性的值,而不是硬编码所有内容。
eval函数之后的输出示例可以在d8中看到,如下所示:
1 | d8> let pNames = []; for (let i = 0; i < 32; i++) {pNames[i] = 'p' + i;} |
现在我们有了这段代码并理解了它是如何工作的,我们可以更新POC脚本,以包括这些新函数,触发bug,然后打印数组和字典的值。我们更新后的脚本现在看起来像这样:
1 | // Create object with one line and 32 out-of-line properties |
当我们在d8中运行更新后的脚本时,我们应该得到类似如下的结果:
1 | C:\dev\v8\v8\out\x64.debug>d8 C:\Users\User\Desktop\poc.js |
太棒了!我们的类型混淆有效,我们能够从字典泄漏数据。从输出中,我们可以看到我们有一些重叠的属性,例如p10与p13重叠(注意负值)。
现在,我们已经确认这段代码可以工作,并且拥有重叠属性,我们可以修改脚本以枚举结果,并选择一个值小于0且大于-32的重叠属性。另外,让我们删除重叠的属性。
更新后的代码如下所示:
1 | // Function that creates an object with one in-line and 32 out-of-line properties |
如果我们再次在d8中运行更新后的代码,我们将看到我们能够始终如一地找到重叠的属性。
1 | C:\dev\v8\v8\out\x64.debug>d8 C:\Users\User\Desktop\poc.js |
理解浏览器利用原语
好了,我们可以利用我们的bug来触发类型混淆,并发现可以用来读取和写入数据的重叠属性。对于那些有敏锐的读者,你可能已经注意到,目前我们只能读取SMI和字符串。本质上,仅仅读取整数或字符串是没有用的,我们需要找到一种方法来读取和写入内存指针。
为了帮助我们实现这一点,我们需要构造一个读写利用原语,分别称为addrOf和fakeObj原语。这些原语将允许我们通过混淆一种类型的对象和另一种类型的对象来利用重叠属性
为了构建这些原语,我们可以滥用当前的类型混淆和在JIT中map消除冗余的工作方式,为我们选择的任何值构造全局类型混淆!
如果您还记得在第1部分和第2部分中,我们讨论了map和BinaryOp以及反馈格栅。我们知道,map存储属性的类型信息,而BinaryOp存储JIT编译期间属性的潜在类型状态。
例如,让我们以下面的代码为例:
1 | function test(obj) { |
在V8中执行这段代码后,obj的Map将显示它有一个属性a是一个SMI,还有一个属性b对象,b有一个属性x也是一个SMI。
如果我们强制这个函数被JIT化,那么对b的Map检查将被省略,因为我们会做出推测性的假设,假设属性b总是一个具有特定Map的对象,允许冗余消除来删除检查。如果此类型信息无效,例如添加属性或将值修改为double,则将分配一个新的Map,并更新BinaryOp以包括SMI和double的类型信息。
考虑到这一点,就有可能滥用这个场景以及我们的重叠属性来构造一个强大的利用原语,该原语将成为我们读写原语的基础。
下面有一个代码示例,它将被用作原语的基础,并带有注释。
1 | eval(` |
如您所见,在数组转换为字典后,p1和p2是我们的重叠属性。通过将p1设置为对象X, p2设置为对象Y,当我们JIT编译vuln函数时,编译器将假设变量p的类型为对象X,因为obj的Map省略了类型检查。
然而,由于我们正在利用的初始类型混淆漏洞,代码实际上将读取属性p2并接收对象Y。在这种情况下,引擎将把对象Y表示为对象X,从而导致另一种类型混淆。
通过使用我们构造的这种全局类型混淆,我们现在可以创建读写原语来泄漏对象地址并写入任意对象字段。
addrOf读原语
addrOf
原语代表“Address Of”,恰如其名。它允许我们通过滥用构造的类型混淆来泄漏特定对象的地址指针。
如上例所示,我们可以通过滥用重叠属性和map存储类型信息的方式来创建全局类型混淆,从而允许我们将对象Y的输出表示为对象X。
那么,问题是,我们如何滥用这个场景来泄漏内存地址?
我们不能简单地传入两个对象并返回一个对象,因为它们的形状相同。如果我们这样做,V8将简单地解除对对象的引用,并返回对象类型或对象的属性。
下面是我们将看到的一个例子:
1 | C:\dev\v8\v8\out\x64.debug>d8 --allow-natives-syntax C:\Users\User\Desktop\poc.js |
正如你所看到的,[object object]的返回值对我们没有用处。相反,我们需要返回对象,但作为不同的类型。
在这种情况下,我们可以通过使对象X为Double来创建一个类型混乱的读原语!这样,当我们调用p1时,它将期望一个双精度值,由于p1实际上返回p2(这是一个对象指针),而不是对指针解引用,它将返回它作为一个浮点数!
让我们看看实际情况。使用前面的示例代码,我们可以修改它来创建一个addrOf函数,方法是将对象X更改为double类型,而将对象Y保留为对象。
函数看起来像这样:
1 | function addrOf() { |
如您所见,我们将p1设置为值为13.37的double类型,并将对象Y设置为由makeObj函数创建的对象。
在通过vuln函数触发漏洞后,引擎会假设obj.p1返回给我们的值。X将是一个double类型,但它会将指针加载到p2对象,并将其作为一个double类型返回。
通过这种方式,我们应该能够泄漏对象地址,但是makeObj函数有一个小问题。目前,makeObj函数创建的对象具有一个内嵌属性和32个外嵌属性。
您可能还记得,这32个行外属性都是负数,我们用它们来避免在查找重叠属性时出现误报。虽然这不是问题,但更大的问题是,在找到重叠的属性之后,我们需要能够在数组的backing store中修改这些特定的属性索引,以便在发生字典转换时能够精确地利用类型混淆。
目前,这是不可能的原因解释如下。
创建对象后,如果尝试在特定索引处修改其属性,则该对象将被添加到属性数组的开头或结尾。此外,我们不能简单地通过命名属性的pN名修改它,因为它没有定义。
下面是一个示例。
1 | d8> let obj = {p1:1, p2:2, p3:3}; |
为了准确地在需要的地方设置对象,我们需要创建一个属性数组,这些属性将在创建过程中传递给对象。这样,通过使用p1和p2的索引,我们可以创建一个属性数组,允许我们精确地设置对象。
下面是一个例子:
1 | d8> let obj = []; |
要做到这一点,让我们修改我们的makeObj函数,以一个pValues数组作为属性,并将pValues[i]设置为值,如下所示:
1 | // Function that creates an object with one in-line and 32 out-of-line properties |
完成这些之后,现在可以修改addrOf函数了。我们首先添加一个新的pValues数组,然后将数组中的p1设置为一个具有double 值的对象,p2设置为一个自定义创建的对象。
1 | function addrOf() { |
如您所见,我们的JIT循环将调用makeObj
来创建一个具有p1和p2属性的对象,然后将其传递给我们的vuln函数以触发类型混淆。if语句检查vuln函数返回的结果是否不等于13.37。如果没有,这意味着我们成功触发了类型混淆,并且已经读取了obj的地址指针。
因为我们正在测试这个,我还添加了一个%DebugPrint
语句来打印出obj
的地址。这允许我们验证返回的数据实际上就是我们的地址。
我们的利用脚本现在看起来像这样。注意,在这个测试用例中,我只是添加了一个对addrOf的调用,它将利用重叠的属性来泄漏函数中硬编码的对象地址。
另外,请注意,我已经修改了finoverlappingproperties
函数,以包含负数的pValues
数组。这样做是为了支持我们对makeObj
函数所做的修改。
1 | // Function that creates an object with one in-line and 32 out-of-line properties |
有了这些,我们现在可以在d8中执行更新后的脚本,并应该得到类似以下的输出:
1 | C:\dev\v8\v8\out\x64.debug>d8 --allow-natives-syntax C:\Users\User\Desktop\poc.js |
正如您所看到的,addrOf函数返回了一个double 浮点值!现在我们需要将这个浮点数转换为实际地址,以便验证其正确性。
为此,我们可以使用TypedArrays,它允许我们描述底层二进制数据缓冲区的类似数组的视图。由于返回给我们的数据是一个双精度浮点值,我们可以使用Float64Array以二进制格式存储我们的双精度值,如下所示:
1 | d8> let floatView = new Float64Array(1); |
一旦完成,我们可以通过 BigUint64Array将我们的floatView
缓冲区转换为64位无符号整数,这应该会给我们对象地址的字节表示。
1 | let uint64View = new BigUint64Array(floatView.buffer); |
从这里开始,使用以16为基数的toString
函数将字节转换为十六进制就很简单了,这应该会给我们一个有效的地址。
1 | d8> uint64View[0].toString(16) |
如所示,一旦我们将字节转换为十六进制,我们就会看到由addrOf
原语泄漏的值与对象的地址000001E72E81A369
匹配!
我们现在有一个工作addrOf
读取原语!
从这里开始,只需要对addrOf函数做一个微小的修改。如果我们想在脚本中进一步使用这个地址,我们必须确保从BigUint64Array
中减去1n来考虑指针标记。
我们的addrOf
函数和它的转换缓冲区现在看起来像这样:
1 | // Conversion Buffers |
fakeObj写原语
fakeObj
原语是“Fake Object”的缩写,它允许我们利用构造的类型混淆将数据写入一个假对象。本质上,写原语只是addrOf原语的逆。
为了创建fakeObj
函数,我们只需对原来的addrOf
函数做一个小修改。在fakeObj函数中,我们将对象的原始值存储在名为origin
的变量中。重写后,返回原始值,并在JIT函数中进行比较。
为了测试,我们尝试用0x41414141n double值重写p1的x属性。由于类型混淆,当JIT代码中的错误触发时,这将覆盖p2中对象的y属性。如果我们成功地破坏了这个值,然后通过orig
形参返回它,它应该不再等于13.37。
fakeObj
函数看起来像这样:
1 | function fakeObj() { |
在用新的fakeObj原语更新代码后,并在d8中执行它,我们应该得到类似如下的输出:
1 | C:\dev\v8\v8\out\x64.debug>d8 --allow-natives-syntax C:\Users\User\Desktop\poc.js |
似乎我们得到了一些数据,它不等于13.37!
这看起来是一个无符号整数,所以我们可以使用uint64View
数组缓冲区来存储值,然后将字节转换为十六进制,就像这样。
1 | d8> uint64View[0] = 1094795585n |
好了!我们成功地覆盖了p2的y属性,并成功地构造了一个有效的写原语!
这是一个非常强大的原语,因为它允许我们写入任何我们可以找到地址的对象属性。从这里我们可以开始构建更复杂的利用原语,最终实现代码执行。
获取内存任意地址读写能力
现在我们已经根据bug创建了可工作的读写原语,我们可以开始利用这些原语在解释器中获得远程代码执行。目前,我们只能用受控double对象覆盖第二个对象的属性。然而,这对我们没有任何用处。
原因是,即使我们可以覆盖一个属性中的对象地址,如果我们试图访问该地址来写数据,V8仍然会尝试解除引用它,并访问从该地址的偏移量8的 backing store指针。这使得我们很难读或写到任何我们选择的地址。
为了用我们的读写原语实现一些有用的东西,我们需要覆盖对象的内部字段,比如 backing store指针,而不是覆盖备份存储中的实际对象或属性。如你所知,后备存储指针存储了一个内存地址,告诉V8我们的属性或元素数组的位置。如果我们可以覆盖这个指针,我们可以告诉V8通过我们的bug访问内存中的任何特定元素!
针对此漏洞,我们必须考虑的下一件事是决定在破坏 backing store指针时要使用什么类型的对象。当然,我们可以使用一个带有外行属性的简单对象,但在我们的例子中,对于大多数浏览器漏洞,我们实际上将使用ArrayBuffer对象。
我们之所以使用ArrayBuffer
而不是普通对象,是因为这些数组缓冲区用于表示固定长度的原始二进制数据缓冲区。需要注意的重要一点是,我们不能在JavaScript中直接操作ArrayBuffer
的内容。相反,我们必须使用具有特定数据表示格式的TypedArray
或DataView
对象,并使用它来读取和写入缓冲区的内容。
我们之前在addrOf
原语中使用TypedArray
将对象的地址作为双浮点数返回,然后将其转换为无符号64位整数,这允许我们将该值转换为十六进制以查看实际地址。通过指定我们想要处理的数据类型,例如整数、浮点数、64位整数等,我们可以在fakeObj
原语中应用相同的原则。通过这种方式,我们可以轻松地读取和写入我们想要的任何类型的数据,而不必过多地担心转换或属性值的类型。
在继续讨论之前,让我们先看看ArrayBuffer
对象在内存中的样子,以便更好地理解如何利用它们。
首先,让我们创建一个新的ArrayBuffer
,长度为8字节,然后为该缓冲区分配一个8位unsigned
视图。
1 | d8> var buffer = new ArrayBuffer(8) |
现在,让我们使用%DebugPrint
命令检查buffer对象。
1 | d8> %DebugPrint(buffer) |
可以看到,ArrayBuffer对象类似于其他V8对象,因为它有一个Map、一个属性和一个元素固定数组,以及数组缓冲区本身的必要属性,比如字节长度和它的backing store。backing store是TypedArray(在本例中是视图变量)将读取和写入数据的地址。
我们可以使用视图变量上的%DebugPrint
函数来确认ArrayBuffer
和TypedArray
之间的关系。
1 | d8> %DebugPrint(view) |
如您所见,TypedArray
有一个buffer属性,指向地址为0x02297c70d881
的ArrayBuffer
。TypedArray
还从父数组缓冲区继承了字节长度属性,因此它知道它可以用特定的数据格式读取和写入多少数据。
为了更好地理解数组缓冲区对象的结构和备份存储,我们可以使用WinDbg来检查它。
1 | 0:005> dq 000002297C70D881-1 L6 |
经过检查,我们可以看到,从左上角开始,我们有Map、properties和elements数组属性存储指针,然后是字节长度,最后是地址00000286692101A0
的 backing store指针,从数组缓冲区开始的偏移量为32。
在研究 backing store缓冲区之前,让我们向缓冲区中添加一些数据,以便更好地查看内存中的表示。要将数据写入ArrayBuffer
,我们必须通过视图变量使用TypedArray
。
1 | d8> view[0] = 65 |
现在,让我们在WinDbg中查看这个后台存储。请注意,我没有从指针中减去1,因为与其他对象备份存储不同,ArrayBuffer
备份存储实际上是一个64位指针!
1 | 0:005> dq 00000286692101A0 L6 |
在检查这个内存地址时,我们注意到在左上角有我们分配给数组缓冲区的8个字节的数据。从右边开始,在索引0中我们有0x41,它是65,在索引2中我们有0x42,它是66。
正如你所看到的,使用ArrayBuffer
和任何数据类型的TypedArray
,只要我们能控制backing store 指针,我们就可以控制我们可以读写数据的位置!
考虑到这一点,让我们弄清楚如何通过fakeObj原语访问这个后备存储指针,以便覆盖它。目前,对于读写原语,我们为p1创建了一个具有内嵌属性的对象,为p2创建了一个同样具有内嵌属性的对象。
1 | function fakeObj() { |
在vuln函数中,我们试图覆盖p1对象的属性x。这将解引用p1的对象地址,并访问偏移量24,其中我们的x属性值内联存储。然而,由于类型混淆,此操作实际上将解引用p2的对象地址并访问偏移量24,其中y属性值内联存储,这将允许我们覆盖obj对象的地址。
下面的示例帮助您可视化内存中的情况。
我们知道数组缓冲区的后备存储指针位于偏移量32,这意味着如果我们创建了另一个内联属性,例如x2
,那么我们应该能够通过fakeObj
原语访问和覆盖backing store指针!
下面提供了一个示例来帮助在内存中可视化这个过程。
这对我们来说很好,因为它允许我们最终利用我们的错误和我们的原语来获得任意内存读/写访问。不过,有一个小问题。考虑这一点。如果我们必须从多个内存位置写入或读取,我们将不得不不断触发我们的错误,并通过fakeObj原语覆盖我们的数组缓冲区backing store ,这是乏味的。因此,我们需要一个更好的解决方案。
为了尽量减少使用fakeObj原语覆盖backing store 的次数,我们可以使用两个数组缓冲区对象而不是一个!这样,我们可以破坏第一个数组缓冲区的后备存储指针,并将其指向第二个数组缓冲区对象的地址。
一旦完成,我们可以使用第一个数组缓冲区的TypedArray
视图写入第5个对象属性(第4个索引,即view1[4]
),这将覆盖第二个数组缓冲区的后备存储指针。从那里,我们可以使用第二个数组缓冲区的TypedArray
视图来向指向的内存区域读写数据!
通过同时使用这两个数组缓冲区,我们可以创建另一个利用原语,它允许我们快速地将任何类型的数据读写到V8堆中的任何位置。
下面是内存中的一个示例。
为了使fakeObj函数更加灵活,我们将修改它以接受我们选择的对象。我们还将传入一个newValue
参数,该参数指定要写入的数据。然后,我们将在vuln函数中为x属性设置newValue
参数,而不是使用硬编码的0x41414141n地址。
1 | function fakeObj(obj, newValue) { |
我们还将修改p1中的对象,使其具有两个内嵌属性,因为我们知道第二个内嵌属性与 store pointer指针重叠。此外,我们需要修改vuln函数以访问第二个内联属性,这样我们就可以写入备份存储指针。
1 | BigInt.prototype.toNumber = function toNumber() { |
注意,对于重叠的p2对象,我们直接将其设置为obj中传递的对象。这样做的原因是我们需要访问特定对象的偏移量32,而不是将对象作为属性传入。
为了正确地转换传入的地址或数据,我们将添加一个名为toNumber
的新转换函数,并针对newValue
参数调用该转换函数。这个函数是必要的,因为我们需要将传递进来的地址或数据转换为浮点数的地址或数据。原因是由于我们构造的类型混淆,以及p1期望一个浮点数!
1 | BigInt.prototype.toNumber = function toNumber() { |
现在是重要的部分,修改JIT循环以触发bug并覆盖 backing store指针。类似于我们之前的fakeObj循环,我们只需要做一些修改。
首先,请注意,我们将p1属性设置为一个新创建的对象o,该对象具有两个内联属性。我们这样做的原因是,在我们的JIT循环期间,我们将需要不断地设置第2个内嵌属性o,以迫使JIT编译器触发Map上的冗余消除。这将允许我们以浮点数的形式访问backing store指针。如果我们不这样做,那么函数将无法工作!
其次,在JIT循环中,我们不再将结果值与13.37进行比较。相反,我们将它与第二个属性的值进行比较。在这种情况下,如果循环不再返回13.38,这意味着我们成功触发了错误并覆盖了backing store指针!
fakeObj原语的最终版本如下所示。
1 | BigInt.prototype.toNumber = function toNumber() { |
现在我们已经完成了,因为我们将为fakeObj原语使用一个具有两个内联属性的对象,让我们对addrOf原语进行相同的修改以保持一致,如下所示。
1 | function addrOf(obj) { |
现在我们已经修改了利用脚本,我们应该能够覆盖一个数组缓冲区backing store指针。让我们来测试一下!
首先,我们将修改利用代码,创建一个包含1024字节数据的新数组缓冲区。然后,我们将尝试泄漏数组缓冲区的地址,并使用0x4141414
1覆盖后备存储指针。
请注意,我添加了两个%DebugPrint
函数来验证我们泄漏的地址与我们实际的数组缓冲区对象一致,并且我们已经成功地覆盖了数组缓冲区的backing store指针。
更新后的脚本末尾代码如下。
1 | print("[+] Finding Overlapping Properties..."); |
在d8中执行更新的利用脚本后,我们得到以下输出。
1 | C:\dev\v8\v8\out\x64.debug>d8 --allow-natives-syntax C:\Users\User\Desktop\poc.js |
正如您所看到的,我们的利用脚本现在成功地泄漏了数组缓冲区的地址,并且我们可以确认调试输出中的地址匹配。我们还看到原始的泄露数据(ret)返回原始的备份存储地址。此外,我们看到我们已经成功地用0x41414141
覆盖了backing store指针,如调试输出所示!
通过覆盖backing store指针的能力,我们可以通过两个数组缓冲区构建我们的内存读/写原语,继续编写我们的exploit。回顾一下,我们需要创建两个数组缓冲区,泄漏第二个数组缓冲区的地址,并用第二个数组缓冲区的地址覆盖第一个数组缓冲区的backing store指针。
完成这个任务的代码如下所示。
1 | print("[+] Finding Overlapping Properties..."); |
这样,我们就能够覆盖arrBuf1的backing store指针,使其指向arrBuf2对象。为此,我们可以为第一个数组缓冲区创建一个TypedArray,并通过BigUint64Array使用一个64位无符号整数读取后台存储指针。这将为我们提供第二个数组缓冲区地址的字节表示形式。
更新后的代码如下所示。
1 | print("[+] Finding Overlapping Properties..."); |
如您所见,在脚本的最后,为了验证覆盖,我们在arrBuf2对象上使用%DebugPrint来确认我们拥有正确的backing store地址。
执行我们的代码,我们得到以下输出。
1 | C:\dev\v8\v8\out\x64.debug>d8 --allow-natives-syntax C:\Users\User\Desktop\poc.js |
它确实有效!正如您在输出中看到的,我们已经成功地泄漏了第二个数组缓冲区的地址,并读取了它的backing store指针,两者都匹配。从这里开始,我们可以通过数组缓冲区继续构建我们的内存读写原语。
由于V8中的所有地址都是32位的,所以我们将使用64位无符号整数类型数组。下面可以看到从上面的示例代码构建的读写原语示例。
1 | let memory = { |
为了测试这是否有效,让我们尝试使用write64
内存原语将值0x41414141n
写入第二个数组缓冲区的backing store。它的代码是这样的:
1 | print("[+] Finding Overlapping Properties..."); |
接下来,我们可以再次使用WinDbg来调试,方法是在RUNTIME_FUNCTION(Runtime_DebugPrint)
上设置一个断点,然后执行脚本。一旦我们到达断点,输入g或按Go,然后按Shift + F11
或“Step Over”,在控制台中看到调试输出。
1 | [+] Finding Overlapping Properties... |
可以看到,backing store指针位于地址0x000002791B474430
。使用WinDbg,让我们查看该地址并确认我们确实写入了该缓冲区。
1 | 0:000> dq 000002791B474430 L6 |
好了!我们已经成功地构建了一个内存读写原语,现在可以将数据写入V8堆中的任何位置。有了这些,我们就可以进入攻击的下一步,即获得远程代码执行!
获取代码执行能力
内存原语就绪后,我们需要找到一种方法让V8执行我们的代码。不幸的是,由于NX已启用,我们不能简单地将shellcode写入或注入到随机的V8堆区域或数组缓冲区中。
这可以通过WinDbg在数组缓冲区的backing store指针上使用vprot函数来验证。
1 | 0:000> !vprot 000002791B474430 |
正如您所看到的,我们只有对这些内存页的读和写访问权,但没有执行权限。
由于我们不能在这些内存页中执行代码,我们需要找到一个不同的解决方案。
一个潜在的解决方案是以JIT内存页为目标。JavaScript代码的JIT编译要求编译器将指令写入内存页,以便稍后执行。由于这是在代码执行时发生的,所以这些页面通常具有RWX权限。这对于我们的内存读/写原语来说是一个很好的目标,我们可以尝试从JIT编译的JavaScript函数中泄漏一个指针,将shellcode写入该地址,然后调用该函数来执行我们自己的代码
然而,在2018年初,V8团队引入了一个名为write_protect_code_memory的保护,它可以在读/执行和读/写之间切换JavaScript的JIT的内存页权限。因此,在JavaScript执行期间,这些页面被标记为读/执行,防止我们将恶意代码写入其中。
绕过这种保护的一种方法是使用面向返回的编程 Return Oriented Programming (ROP)。使用ROP,我们可以利用虚表(存储虚函数的地址)、JIT函数指针,甚至破坏堆栈。
ROP小工具如何利用虚表的例子可以在博客文章“One Day Short of a Full Chain: Part 3 - Chrome Renderer RCE”和Connor McGarr的“Browser Exploitation on Windows”中找到。
虽然ROP是一种有效的利用开发技术,但我喜欢以“Work Smart Not Hard”为座右铭,不需要做大量的工作。幸运的是,JavaScript并不是V8中唯一被编译的语言,还有 WebAssembly !
WebAssembl基础解析
WebAssembly(也称为wasm)是一种用于浏览器客户端执行的低级编程语言,通常用于支持C/ c++和类似的语言。
WebAssembly的好处之一是它允许WebAssembly模块和JavaScript上下文之间的通信。这使得WebAssembly模块可以通过与JavaScript相同的Web api访问浏览器功能。
最初,V8引擎不编译WebAssembly。相反,wasm函数通过称为Liftoff的基线编译器进行编译。Liftoff迭代WebAssembly代码一次,并立即为每个WebAssembly指令发出机器代码,类似于SparkPlug将ignations字节码发送到机器代码。
由于wasm也是JIT编译的,它的内存页被标记为Read-Write-Execute权限。wasm有一个相关的写保护标志,但默认情况下由于asm.js文件而禁用,如Johnathan Norman所述。这使得wasm成为我们开发工作的有价值的工具。
在我们在开发工作中使用WebAssembly之前,我们首先需要了解一点它的结构以及它是如何工作的。不幸的是,我不会解释关于WebAssembly的所有内容,因为它本身可以是一篇单独的博客文章。因此,我将只介绍我们需要知道的重要部分。
在WebAssembly中,一段编译好的代码被称为“module”。然后,这些模块被instantiated以生成一个称为“[instance](https://developer.mozilla.org/en-US/docs/WebAssembly/JavaScript_interface/Instance#:~:text=A WebAssembly.,into WebAssembly code from JavaScript.)”的可执行对象。实例是一个对象,它包含所有导出的WebAssembly函数exported WebAssembly functions,这些函数允许从JavaScript调用WebAssembly代码。
在V8引擎中,这些对象分别被称为WasmModuleObject
和WasmInstanceObject
,可以在V8 /src/wasm/wasm-objects.h
源文件中找到。
WebAssembly是一种二进制指令格式,它的模块类似于可移植可执行文件(PE)。与PE文件一样,WebAssembly模块也包含节。在WebAssembly模块中大约有11个标准节:
- 类型
- 导入
- 函数
- 表
- 内存
- 全局
- 导出
- 开始
- 元素
- 代码
- 数据
对于每个部分的更详细的解释,我建议阅读“Introduction to WebAssembly”文章。
我想重点讲的是表Table部分。在WebAssembly中,表是一种映射不能被WebAssembly表示或直接访问的值的机制,比如GC引用、原始操作系统句柄或本机指针。此外,每个表都有一个元素类型,用于指定它所保存的数据类型。
在WebAssembly中,每个实例都有一个指定的“默认”表,由call_indirect
操作索引。此操作是一条指令,用于调用默认表中的函数。
2018年,V8开发团队更新了WebAssembly,为use jump tables for all calls,以实现惰性编译和更高效的分层。因此,V8中的所有WebAssembly函数调用都会调用该跳转表中的一个槽,然后该槽跳转到实际编译的代码(或WasmCompileLazy
存根)。
在V8中,跳转表(也称为代码表)作为WebAssembly中所有(直接和间接)调用的中心分派点。跳转表为模块中的每个函数保留一个插槽,每个插槽包含对当前发布的对应于相关函数的WasmCode
的分派。关于跳转表实现的更多信息可以在/src/wasm/jump-table- assembly .h
源文件中找到。
当生成WebAssembly代码时,编译器将其输入到代码表中,并为特定实例修补跳转表,从而使其对系统可用。然后它返回一个指向WasmCode
对象的原始指针。因为这段代码是JIT编译的,所以指针指向具有RWX权限的内存段。每次调用WasmCode
对应的函数时,V8都会跳转到该地址并执行编译后的代码。
这个由跳转表指向的RWX内存段是我们想用内存读/写原语来实现远程代码执行的目标!
滥用WebAssembly 内存
现在我们对WebAssembly有了更好的理解,并且知道需要将跳转表指针作为远程代码执行的目标,让我们编写一些wasm代码,并研究它在内存中的样子,以便更好地理解如何将它用于我们的利用。
编写wasm代码的一种简单方法是使用WasmFiddle,它将允许我们编写C代码,并获得运行它所需的代码缓冲区和JavaScript代码的输出。使用默认代码返回42,我们得到以下JavaScript代码。
1 | var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]); |
在d8中执行这段代码后,我们可以对wasmInstance
变量使用%DebugPrint
,该变量将是存放函数导出的可执行模块对象。如您所见,在wasm代码的最后一行中,我们将func变量设置为指向该wasm实例的main函数导出,该导出将指向可执行的wasmCode
。
这样做,我们将得到以下输出。
1 | d8> %DebugPrint(wasmInstance) |
在分析输出之后,我们可以看到没有对代码或跳转表的引用。然而,如果我们查看V8中WasmInstanceObject的代码,就会发现函数中有一个对jump_table_start
条目的访问器。该条目应该指向存储机器代码的RWX内存区域。
在V8中,这个jump_table_start
条目有一个偏移量,但是它在V8版本之间会定期变化。因此,我们需要手动定位该地址存储在WasmInstanceObject
中的位置。
为了帮助我们找到这个地址在WasmInstanceObject
中的存储位置,我们可以在WinDbg中使用!address命令来显示d8进程所使用的内存信息。因为我们知道jump_table_start
地址具有RWX权限,所以我们可以通过PAGE\_EXECUTE\_READWRITE
protection常量来过滤地址输出,以查找任何新创建的RWX内存区域。
这样做的结果是以下输出。
1 | 0:004> !address -f:PAGE_EXECUTE_READWRITE |
在本例中,地址0x556C400000
似乎是RWX内存区域的跳转表项。让我们通过检查wasmInstace
对象地址在WinDbg中的内存内容来验证WasmInstanceObject
是否确实存储了这个指针。
1 | 0:004> dq 0000032B465226A9-1 L22 |
在分析输出之后,我们可以看到指向RWX内存页的跳转表项指针确实存储在wasmInstance
对象中,地址为0x32b46522798
!
从这里开始,我们可以做一些简单的十六进制计算,以找到与WasmInstanceObject
的基址减去1(由于指针标记)相比RWX页的偏移量。
1 | 0x798 – (0x6A9-0x1) = 0xF0 (240) |
这样,我们就知道跳转表的偏移量与实例对象的基址的距离为240
字节(或0xF0
)。
现在,我们可以通过添加上面的WebAssembly示例代码来更新我们的利用脚本,然后尝试泄漏跳转表项的RWX地址!
然而,我们有一个小问题。不幸的是,我们不能再使用addrOf原语来泄漏对象地址了。原因是addrOf原语通过覆盖重叠属性滥用了我们的bug。这实际上会破坏我们通过数组缓冲区设置的内存读/写原语,导致写入错误的内存区域,并可能导致崩溃。
在这种情况下,我们需要通过数组缓冲区利用内存读/写原语来泄漏对象地址。使用已经拥有的,可以通过数组缓冲区构建另一个addrOf原语,方法如下:
- 向第二个数组缓冲区添加一个out-line属性。
- 泄漏第二个数组缓冲区属性存储的地址。
- 使用read64内存原语读取属性存储区中偏移16处对象的地址。
在我们实现它之前,让我们看看它在内存中的样子,以确认它将工作。让我们首先创建一个名为arrBuf1
的新数组缓冲区,然后创建一个随机对象,如下所示。
1 | d8> let arrBuf1 = new ArrayBuffer(1024); |
接下来,让我们为arrBuf1
设置一个新的Out line属性leakme
,并将对象设置为它的值。
1 | d8> arrBuf1.leakme = obj; |
如果我们对arrBuf1
运行%DebugPrint
命令,我们将看到现在在属性数据存储中存储了一个新的out-line属性。
1 | d8> %DebugPrint(arrBuf1) |
正如我们所看到的,obj
的地址是0x03b88950f951
。如果我们用WinDbg查看属性存储中的arrBuf1
,我们可以看到在属性存储中的偏移量16处,我们有对象的地址!
1 | 0:005> dq 0x03b88950fe29-1 L6 |
好的,我们确认了这是可行的。在这种情况下,让我们继续为我们的内存读写原语实现一个新的addrOf
函数,如下所示:
1 | let memory = { |
实现后,我们终于可以更新我们的利用脚本,以包括新的addrOf
原语和WebAssembly
代码。之后,我们可以尝试泄漏wasmInstance
和实例RWX跳转表页的地址。
更新后的脚本如下所示:
1 | print("[+] Finding Overlapping Properties..."); |
在d8中执行更新后的脚本时,我们将注意到以下输出。
1 | [+] Finding Overlapping Properties... |
似乎我们成功地将地址泄漏给了wasmInstance
和jump_table_start
指针。
为了确认泄露的地址是有效的,我们可以使用WinDbg检查wasmInstance
地址以验证对象结构,并检查偏移量240处是否有跳转表地址。
1 | 0:000> dq 0x2998447e580 L22 |
在检查内存时,我们确认我们成功地泄漏了有效地址,因为000002998447e670
包含跳转表开始条目的指针!
好了,我们快到最后阶段了!现在我们有了一个指向RWX内存页的有效跳转表地址,我们所要做的就是将shellcode写入内存区域,然后触发WebAssembly函数来执行代码!
在这篇博文中,我将使用一个Null-Free WinExec PopCalc shellcode,它将在成功执行计算器应用程序时简单地执行。当然,这取决于读者为他们自己的脚本实现他们想要的任何shellcode !
因为我们原来的WebAssembly代码是使用 Uint8Array,我们必须确保我们包装我们的shellcode在相同类型的数组表示。下面是一个示例,展示了脚本中的pop calc shellcode。
1 | // Prepare Calc Shellcode |
在准备好我们的shellcode之后,我们现在需要通过数组缓冲区添加一个新的内存write
原语,因为我们当前的write64
函数只使用BigUint64Array表示写入数据。
对于这个写原语,我们可以重用write64
的代码,但有两个较小的更改。首先,我们需要使view2
为Uint8Array
而不是BigUint64Array
。其次,为了通过视图编写完整的shellcode,我们将调用set函数。这允许我们在数组缓冲区中存储多个值,而不是像以前那样只使用索引。
新的写内存原语如下所示:
1 | let memory = { |
完成后,剩下要做的就是更新利用脚本以包括新的“write
”原语,添加我们的shellcode,将其写入泄漏的跳转表地址,最后调用我们的WebAssembly函数来执行我们的shellcode!
最终更新的利用脚本将如下所示。
1 | print("[+] Finding Overlapping Properties..."); |
是时候执行我们的利用了!如果一切按计划进行,一旦WebAssembly函数被调用,它应该执行我们的shellcode,计算器应该弹出!
好了,揭晓真相的时刻到了。见证奇迹!
好了!我们的利用脚本有效,我们能够成功地执行我们的shellcode!
总结
好了,我们知道了!在花了三个月的时间学习Chrome和V8的内部结构后,我们成功地分析和利用了CVE-2018-17463!这是一个不小的壮举,因为Chrome漏洞利用是一个复杂而具有挑战性的任务。
在整个系列中,我们已经建立了一个强大的知识基础,为我们处理Chrome漏洞利用的更复杂的任务做好了准备。最后,我们成功地分析并利用了Chrome中的一个真实漏洞,展示了我们所学概念的实际应用。
总的来说,本系列文章提供了一个详细而深入的Chrome漏洞利用世界,我希望它对读者既长见识又有用。无论您是经验丰富的安全研究人员还是刚刚入门,我希望您从本系列文章中获得了有价值的见解和知识。
我想真诚地感谢你坚持到最后,感谢你对这个话题的兴趣!
对于那些感兴趣的人来说,这个项目的最终利用代码已经添加到我的Github上的 CVE-2018-17463存储库中。
感谢您的阅读,干杯!